4.1 KB140 lines
Blame
1import type { Metadata } from "next";
2import Link from "next/link";
3import { timeAgo, encodePath } from "@/lib/utils";
4import { getRepoBlame } from "@/lib/grove-api";
5
6interface Props {
7 params: Promise<{ owner: string; repo: string; path: string[] }>;
8}
9
10export async function generateMetadata({ params }: Props): Promise<Metadata> {
11 const { repo, path: pathParts } = await params;
12 const filePath = pathParts.slice(1).join("/");
13 const fileName = filePath.split("/").pop() ?? filePath;
14 return { title: `Blame · ${fileName} · ${repo}` };
15}
16
17
18export default async function BlamePage({ params }: Props) {
19 const { owner, repo, path: pathParts } = await params;
20 const ref = pathParts[0] ?? "main";
21 const path = pathParts.slice(1).join("/");
22
23 type BlameLine = {
24 hash: string;
25 author?: string;
26 timestamp?: number;
27 line_no: number;
28 content: string;
29 };
30 const data = await getRepoBlame<{ blame: BlameLine[] }>(owner, repo, ref, path);
31
32 if (!data) {
33 return (
34 <div className="max-w-3xl mx-auto px-4 py-16">
35 <h1 className="text-lg" style={{ color: "var(--text-secondary)" }}>
36 Could not load blame
37 </h1>
38 </div>
39 );
40 }
41
42 // Track commit hash changes for alternating bands
43 let currentHash = "";
44 let bandIndex = 0;
45
46 return (
47 <div className="max-w-3xl mx-auto px-4 py-6">
48 <div className="flex items-center gap-1 text-sm mb-4">
49 <Link
50 href={`/${owner}/${repo}/blob/${ref}/${encodePath(path)}`}
51 style={{ color: "var(--accent)" }}
52 className="hover:underline"
53 >
54 {path}
55 </Link>
56 <span
57 className="ml-2 text-xs px-1.5 py-0.5"
58 style={{
59 color: "var(--text-muted)",
60 backgroundColor: "var(--bg-card)",
61 border: "1px solid var(--border-subtle)",
62 }}
63 >
64 blame
65 </span>
66 </div>
67
68 <div className="text-xs mb-4">
69 <Link
70 href={`/${owner}/${repo}/blob/${ref}/${encodePath(path)}`}
71 style={{ color: "var(--accent)" }}
72 className="hover:underline"
73 >
74 View source
75 </Link>
76 </div>
77
78 <div
79 className="overflow-x-auto"
80 style={{ border: "1px solid var(--border-subtle)" }}
81 >
82 <table className="w-full text-sm font-mono">
83 <tbody>
84 {data.blame.map((line: any, i: number) => {
85 if (line.hash !== currentHash) {
86 currentHash = line.hash;
87 bandIndex++;
88 }
89 const isAlt = bandIndex % 2 === 0;
90
91 return (
92 <tr
93 key={i}
94 style={{
95 backgroundColor: isAlt ? "var(--bg-card)" : undefined,
96 }}
97 >
98 <td
99 className="text-xs pr-3 py-0 w-16 truncate pl-3"
100 style={{ color: "var(--text-faint)" }}
101 >
102 {line.hash.slice(0, 7)}
103 </td>
104 <td
105 className="text-xs px-2 py-0 w-24 truncate"
106 style={{ color: "var(--text-muted)" }}
107 >
108 {line.author}
109 </td>
110 <td
111 className="text-xs px-2 py-0 w-16"
112 style={{ color: "var(--text-faint)" }}
113 >
114 {timeAgo(line.timestamp)}
115 </td>
116 <td
117 className="text-right select-none px-2 py-0 w-8"
118 style={{
119 color: "var(--text-faint)",
120 borderRight: "1px solid var(--divide)",
121 }}
122 >
123 {i + 1}
124 </td>
125 <td
126 className="pl-4 py-0 whitespace-pre"
127 style={{ color: "var(--text-secondary)" }}
128 >
129 {line.content}
130 </td>
131 </tr>
132 );
133 })}
134 </tbody>
135 </table>
136 </div>
137 </div>
138 );
139}
140